fix: correct transport request-body handling for body-less and unbufferable streaming bodies#82
Merged
Merged
Conversation
…erable bodies The SDK's Request model treats a request body as optional for every method, but the two transports mishandled two edge cases of that contract. OkHttp transport: a body-less POST/PUT/PATCH crashed. RequestAdapter passed a null body straight to okhttp3.Request.Builder.method, which throws IllegalArgumentException for the methods OkHttp treats as requiring a body. A body-less POST is a valid, publicly-constructible SDK request, so this turned a normal request into an unchecked exception that bypassed the pipeline and diverged from the JDK transport (which already substitutes an empty body). The adapter now substitutes a shared zero-length body for POST/PUT/PATCH when no body is present; other methods keep their null body. An explicit three-token check is used rather than OkHttp's internal HttpMethod.requiresRequestBody: that symbol is internal to OkHttp's Kotlin module, and the SDK's Method enum cannot produce the WebDAV tokens (PROPPATCH/REPORT) that distinguish the two sets. JDK transport: a non-replayable streaming body whose writeTo failed mid-write surfaced the wrong exception. When toReplayable() threw, streamingPublisher fell back to bufferToByteArray, which called writeTo a second time. The SDK's consume-once bodies flip their consumed guard before writing, so the second writeTo tripped that guard and threw IllegalStateException, masking the original IOException and escaping the @throws(IOException) contract on both execute paths. The partial bytes from the first attempt were already gone. The catch now rethrows the original IOException wrapped in an UncheckedIOException with a clear message instead of re-driving the consumed body. Adds MockWebServer coverage for body-less POST/PUT/PATCH on the OkHttp transport and a non-replayable mid-write-failure body on the JDK transport.
This was referenced Jun 16, 2026
When toReplayable() fails mid-write while buffering a non-replayable streaming body, the JDK transport now rethrows a checked IOException wrapping the original cause, instead of an UncheckedIOException. The body is already partially consumed and unrecoverable at that point, so the request must fail — but it is an I/O failure and belongs in execute()'s @throws(IOException) contract. Throwing it unchecked bypassed that contract and diverged from the eager (<=64 KiB) path, which already propagates a mid-write failure as an IOException. The retry pipeline gates on isReplayable(), so a non-replayable body is never re-driven regardless of exception type.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes two defects in how the transports adapt request bodies.
1. OkHttp transport rejects a body-less POST/PUT/PATCH
The SDK request model treats a body as optional for every method, so a body-less
POST(orPUT/PATCH) is a valid, constructible request. The OkHttp adapter passed anullbody straight toRequest.Builder.method, which throwsIllegalArgumentException("method POST must have a request body.")for the methods OkHttp treats as requiring one. That exception is unchecked, so it escapes the transport'sIOExceptionhandling and bypasses the retry pipeline — and it diverges from the JDK transport, which already substitutes an empty body for this case.The adapter now substitutes a shared, zero-length request body for
POST/PUT/PATCHwhen the SDK request carries no body; other methods keep theirnullbody. An explicit method check is used rather than OkHttp'sinternalHttpMethodsymbol to avoid coupling the transport to an unstable cross-module API (the SDK's method enum cannot produce OkHttp's two WebDAV tokens, soPOST/PUT/PATCHis the exact reachable set).2. JDK transport re-drives an already-consumed body on a buffering failure
When
streamingPublisherfailed to buffer a non-replayable body into a replayable copy (toReplayable()threw mid-write), its fallback calledwriteToa second time on the same body. Consume-once bodies flip their guard before writing, so the secondwriteTotripped that guard and threwIllegalStateException, masking the originalIOExceptionand escaping the method's@Throws(IOException)contract. The "partial bytes" the fallback comment claimed to emit had already been discarded.The fallback no longer re-drives the body. It fails loudly with an
UncheckedIOExceptionthat wraps the originalIOExceptionand explains that the streaming body was partially consumed and a replayable body is required.Tests
POST,PUT, andPATCHdispatch successfully with an empty recorded body (previously threwIllegalArgumentException).writeTofails mid-write now surfaces anUncheckedIOExceptioncarrying the original cause, not anIllegalStateException.Internal change only — no public API change.
Closes #21
Closes #16